Chomu's Blog.

>

Posts

GitHub

fp-ts 로 데이터 검증하기 3 - 데이터의 오류 확인하기

목차

지난 글에서

지난 글에서는 데이터에 Option 을 이용해 오류 유무만을 확인했다.
이번 글에서는 정확히 어떤 오류가 있는지 확인하는 방법을 알아보자.

Either

EitherOption 과 비슷하지만, 오류가 발생했을 때 오류의 내용을 담을 수 있다.
OptionEither 의 일종이라고 생각하면 된다.
Either 는 두 가지의 타입을 받는데, 보통 성공적으로 처리된 경우를 Right, 오류가 발생한 경우를 Left 라고 한다. Option 은 이 중 LeftNone 으로 고정된 Either 라고 생각하면 된다.

Either 적용

공식 문서에 나와있는 예시를 보고 Either 를 어떻게 사용할 수 있는지 알아보자.
예를 들어 number[] 에서 첫 번째 요소를 가져와 역수를 취하는 함수를 만들어보자.
이 때 두 가지를 검사해야하는데, 배열이 비어있지 않은지, 첫 번째 요소가 0이 아닌지이다.
fp-ts 를 사용하지 않는다면 다음과 같이 구현할 수 있을 것이다.

const inverse = (n: number): number => {
  if (n === 0) {
    throw new Error('cannot divide by zero')
  }
  return 1 / n
}
const head = (as: Array<number>): number => {
  if (as.length === 0) {
    throw new Error('empty array')
  }
  return as[0]
}
export const imperative = (as: ReadonlyArray<number>): string => {
  try {
    return `Result is ${inverse(head(as))}`
  } catch (err: any) {
    return `Error is ${err.message}`
  }
}

익숙하긴 하지만 매번 오류를 throw 하고 try-catch 로 잡아내는 것은 번거롭다.
대신 올바른 값을 Right 라는 타입을 만들어서 담아보자.

interface Right<A> {
  readonly _tag: 'Right'
  readonly right: A
}
 
const head = (e: Right<number[]> | any): number => {
  if (e._tag === "Right") {
    if (e.right.length === 0) return {}
    return {
      _tag: "Right"
      right: e.right[0]
    }
  }
  return {}
}
const inverse = (e: Right<number> | any): Right<number> => {
  if (e._tag === "Right") {
    if (e.right === 0) return {}
    return {
      _tag: "Right"
      right: 1 / e.right
    }
  }
  return {}
}
export const useRight = (as: ReadonlyArray<number>): string => {
  const result = inverse(head(as))
  return result._tag === "Right" ? `Result is ${result.right}` : `Error`
}

하지만 이렇게만 하면 어디서 에러가 발생했는지 알 수 없다.
이를 위해 에러를 담기 위한 Left 타입을 만들어보자.

interface Left<E> {
  readonly _tag: 'Left'
  readonly left: E
}
 
const head = (e: Left<string> | Right<number[]>): Left<string> | Right<number> => {
  if (e._tag === "Right") {
    if (e.right.length === 0) return { _tag: "Left", left: "empty array" }
    return { _tag: "Right" right: e.right[0] }
  }
  return e as Left<string>
}
const inverse = (e: Left<string> | Right<number>): Left<string> | Right<number> => {
  if (e._tag === "Right") {
    if (e.right === 0) return { _tag: "Left", left: "cannot divide by zero" }
    return { _tag: "Right", right: 1 / e.right }
  }
  return e as Left<string>
}
export const useLeftRight = (as: ReadonlyArray<number>): string => {
  const result = inverse(head(as))
  return result._tag === "Right" ? `Result is ${result.right}` : `Error is ${result.left}`
}

이제 공통적인 부분을 분리해보자.
먼저 LeftRight 를 만드는 함수를 만들어보자.

const left = <E>(e: E): Left<E> => ({ _tag: "Left", left: e })
const right = <A>(a: A): Right<A> => ({ _tag: "Right", right: a })
const head = (e: Left<string> | Right<number[]>): Left<string> | Right<number> => {
  if (e._tag === "Right") {
    return e.right.length === 0 ? left("empty array") : right(e.right[0])
  }
  return e as Left<string>
}
const inverse = (e: Left<string> | Right<number>): Left<string> | Right<number> => {
  if (e._tag === "Right") {
    return e.right === 0 ? left("cannot divide by zero") : right(1 / e.right)
  }
  return e as Left<string>
}

그리고 if (e._tag === "Right") { ... } return echain 으로 바꿔보자.
pipefp-tspipe 함수이다.

const chain = <E, A, B>(f: (a: A) => Left<E> | Right<B>) => (ma:Left<E> | Right<A>): Left<E> | Right<B> => 
  ma._tag === "Right" ? f(ma.right) : ma
const head = (e: Left<string> | Right<number[]>): Left<string> | Right<number> => {
  return (as: number[]) => as.length === 0 ? left("empty array") : right(as[0])
}
const inverse = (e: Left<string> | Right<number>): Left<string> | Right<number> => {
  return (n: number) => n === 0 ? left("cannot divide by zero") : right(1 / n)
}
const useChain = (as: number[]) => {
  const result = pipe(
    as,
    right,
    chain(head),
    chain(inverse)
  )
  return result._tag === "Right" ? `Result is ${result.right}` : `Error is ${result.left}`
}

그리고 받은 인자를 right 로 만들어주는 ofLeft, Right 인 경우 동작이 달라지는 match 를 만들어보자.

const of = <E, A>(a: A): Left<E> | Right<A> => right(a);
const match = <E, A, B>(onLeft: (e: E) => B, onRight: (a: A) => B) => (ma: Left<E> | Right<A>): B =>
  ma._tag === "Right" ? onRight(ma.right) : onLeft(ma.left)
const useOfMatch = (as: number[]) => pipe(
    as,
    of,
    chain(head),
    chain(inverse),
    match(
      (err) => `Error is ${err}`,
      (head) => `Result is ${head}`
    )
  )

그리고 이 LeftRight 를 합친 타입 Either 를 만들어보자.

type Either<E, A> = Left<E> | Right<A>
 
const chain = <E, A, B>(f: (a: A) => Either<E, B>) => (ma: Either<E, A>) => Either<E, B>
const head = (e: Either<string, number[]>): Either<string, number> => {
  return (as: number[]) => as.length === 0 ? left("empty array") : right(as[0])
}
const inverse = (e: Either<string, number>): Either<string, number> => {
  return (n: number) => n === 0 ? left("cannot divide by zero") : right(1 / n)
}
const of = <E, A>(a: A): Either<E, A> => right(a);
const match = <E, A, B>(onLeft: (e: E) => B, onRight: (a: A) => B) => (ma: Either<E, A>): B =>
  ma._tag === "Right" ? onRight(ma.right) : onLeft(ma.left)
const useEither = (as: number[]) => pipe(
    as,
    of,
    chain(head),
    chain(inverse),
    match(
      (err) => `Error is ${err}`,
      (head) => `Result is ${head}`
    )
  )

fp-tsEither 를 사용하면 다음과 같이 구현할 수 있다.

import * as E from "fp-ts/Either"
 
const head = (as: number[]): Either<string, number> =>
  as.length === 0 ? E.left("empty array") : E.right(as[0]);
const inverse = (n: number): Either<string, number> =>
  n === 0 ? E.left("cannot divide by zero") : E.right(1 / n);
 
const functional = (as: number[]) =>
  pipe(
    as,
    E.of<string, number[]>,
    E.chain(head),
    E.chain(inverse),
    E.match(
      (err) => `Error is ${err}`,
      (head) => `Result is ${head}`
    )
  );

훨씬 더 간결해졌다.

여러 개의 에러 처리하기

위와 같은 경우에는 하나의 에러만 처리해도 되지만 동시에 여러가지 에러가 발생할 수도 있다.
지난 글에서 했던 UserInput 검사를 Either 로 바꿔보자.
먼저 간단하게 최소 글자수 검사만 해보자.

const isPasswordValid = (body: Body) =>
  minLength(8)(body.password) ? E.right(body) : E.left("password is too short");
const isUsernameValid = (body: Body) =>
  minLength(6)(body.username) ? E.right(body) : E.left("username is too short");
 
const isUserInputValid = (body: Body) =>
  pipe(
    body,
    E.of,
    E.chain(isPasswordValid),
    E.chain(isUsernameValid),
  );
 
const stringify = E.match<string[], UserInput, string>(
  (err) => `Error: ${err.join(", ")}`,
  ({ username, password }) =>
    `Result: username is ${username}, password is ${password}`
);
const exampleUserInput = [
  { username: "Giulio", password: "password" },
  { username: "Giulio", password: "pass" },
  { username: "Giu", password: "password" },
  { username: "Giu", password: "pass" },
];
console.log(exampleUserInput.map(isUserInputValid).map(stringify).join("\n"));
/* Output:
Result is username: Giulio, password: password
Error is password is too short
Error is username is too short
Error is password is too short
*/

우리가 최종적으로 만들고 싶은 것은 4번째 예제에서 usernamepassword 모두 에러가 발생했을 때, 두 개의 에러를 모두 출력하는 것이다.
그러기 위해서는 각각의 에러문을 배열로 합치는 것이 이상적일 것이다.
즉 다음과 반환값이 Either<string[], T> 를 합치는 함수를 만들어야한다.

| Either1 | Either2 | 결과 | | --- | --- | --- | | Right<A> | Right<B> | Right<A & B> | | Right<A> | Left<B> | Left<B> | | Left<A> | Right<B> | Left<A> | | Left<A> | Left<B> | Left<A + B> |

이를 직접 구현한다면 다음과 같은 모습일 것이다.

const concatEithers = (
  e1: E.Either<string[], { username: string }>,
  e2: E.Either<string[], { password: string }>
) =>
  E.isLeft(e1)
    ? E.isLeft(e2)
      ? E.left([...e1.left, ...e2.left])
      : e1
    : E.isLeft(e2)
    ? e2
    : E.right({ ...e1.right, ...e2.right });

getApplicativeValidation

다행히 fp-ts 에서는 이를 위한 함수가 존재한다.
EithergetApplicativeValidation 라는 함수이다.
다만 사용법을 조금 숙지해야한다.
먼저 getApplicativeValidationSemigroup 을 받아 Applicative 를 반환하는 함수이다.
입력하는 SemigroupEitherleft 에 담긴 값들을 합치는 Semigroup 이다.
반환는 Applicativeap 를 통해 사용하는데 지난 글에서 설명했듯, ap 는 컨테이너에 담긴 함수와 값을 받아 함수에 값을 넣어 반환하는 함수이다.
따라서 다음과 같은 방식으로 사용해야한다.

const { ap } = E.getApplicativeValidation(A.getMonoid<string>());
ap(
  E.right((a: number) => a + 1),
  E.right(2)
); // Right(3)
ap(
  E.right((a: number) => a + 1),
  E.left(["error"])
); // Left(["error"])
ap(
  E.left(["error1"]),
  E.left(["error2"])
); // Left(["error1", "error2"])

이를 이용하면 username 을 합치는 부분을 이렇게 바꿀 수 있다.

const errorsApplicative = E.getApplicativeValidation(A.getMonoid<string>());
const validUsername = (username: string) =>
  minLength(6)(username) ? E.right(username) : E.left(["username too short"]);
const validUserInput = (input: Body) => pipe(
  ...
  (ea) =>
    errorsApplicative.ap(
      E.isLeft(ea)
        ? ea
        : E.right((b: string) => ({ ...ea.right, username: b })),
      validUsername(input.username)
    ),
  ...
)

ApplicativeFunctor 이므로 map 을 사용할 수 있다.
이를 통해 더 간결하게 쓸 수 있다.

const validUserInput = (input: Body) => pipe(
  ...
  (ea) =>
    errorsApplicative.ap(
      errorsApplicative.map(ea, (a) => (b: string) => ({ ...a, username: b })),
      validUsername(input.username)
    ),
  ...
)

하지만 이를 더 쉽게 사용할 수 있는 방법이 있다.

apS

Apply에는 apS 라는 함수가 존재한다.
apS 는 컨테이너에 담긴 레코드 값에 새로운 성분을 추가한 레코드를 반환하는 함수이다.
세 번이나 호출을 해야하는 고차함수이나, 그만큼 편리하게 사용할 수 있다.
첫 번째 호출 시에는 컨테이너의 Apply 를 넣는다.
두 번째 호출 시에는 성분명과 해당 성분에 넣을 값이 담긴 컨테이너를 넣는다.
세 번째 호출 시에는 컨테이너에 담긴 레코드를 넣는다.
apS 의 최종 반환값의 타입은 해당 성분의 존재와 타입을 보장한다.

Applicative 는 당연히 Apply 이므로 apS 를 사용할 수 있다.

import { apS } from "fp-ts/Apply";
 
const errorsApplicative = E.getApplicativeValidation(A.getMonoid<string>());
const errorsApS = apS(errorsApplicative);
 
const validUserInput = ({ username, password }: UserInput) =>
  pipe(
    E.right({}),
    errorsApS("username", validUsername(username)),
    errorsApS("password", validPassword(password)),
  );

Do notation

추가적으로 E.right({}) 는 자주 쓰이기 때문에 E.Do 라는 이름으로 사용할 수 있다.

const validUserInput = ({ username, password }: UserInput) =>
  pipe(
    E.Do,
    errorsApS("username", validUsername(username)),
    errorsApS("password", validPassword(password)),
    E.match(
      (err) => `Error is ${err.join(", ")}`,
      ({ username, password }) =>
        `Result is username: ${username}, password: ${password}`
    )
  );
 
const examples = [
  { username: "Giulio", password: "password" },
  { username: "Giulio", password: "pass" },
  { username: "Giu", password: "password" },
  { username: "Giu", password: "pass" },
];
console.log(examples.map(validUserInput).join("\n"));
/* Output:
  Result is username: Giulio, password: password
  Error is password too short
  Error is username too short
  Error is username too short, password too short
*/

특히 방금 쓰였던 apSbind, exists 등의 함수는 E.Do 와 자주 쓰인다.
이와 같은 방법론을 "Do notation" 이라고 한다.

validate

이제 지난 글에서 만들었던 validate 함수를 Either 로 바꿔보자.
지난 글에서는 Option 을 사용했기 때문에 검사만 하면 됐었다.
이번 글에선 Either 로 에러 메시지를 담아야하기 때문에 검사함수 pred 와 오류 시 반환할 메시지 error 를 같이 받아야한다.
검사할 함수들을 모두 문제 없이 통과한다면 기존 값을 담고 있는 Right 를 반환해야한다.
하지만 검사 중 하나라도 문제가 있다면 문제가 있는 모든 에러문이 담긴 배열이 담긴 Left 를 반환해야한다.

간단하게 명령형으로 먼저 구현해보자.

const validate_ =
  <T>(predAndError: [Predicate<T>, string][]) =>
  (v: T) => {
    const errors: string[] = [];
    for (const [pred, error] of predAndError) {
      if (!pred(v)) {
        errors.push(error);
      }
    }
    if (errors.length > 0) {
      return E.left(errors);
    }
    return E.of(v);
  };

눈에 보이는 것부터 천천히 바꿔보자.
먼저 for (...) { if (...) {...} }filter 로 바꿀 수 있다.
그리고 그 중에서 두번째 인자인 error 를 가져오기 위해 map 을 사용하자.

const validate =
  <T>(predAndError: [Predicate<T>, string][]) =>
  (v: T) => {
    const errors = predAndError.filter(([pred]) => !pred(v)).map(([, error]) => error);
    return errors.length > 0 ? E.left(errors) : E.of(v);
  };

filtermap 에서 사용된 특정 인자를 갖고 오는 함수는 Tuplefst, snd 라는 함수로 대체할 수 있다.

import * as T from "fp-ts/Tuple";
 
const validate =
  <T>(predAndError: [Predicate<T>, string][]) =>
  (v: T) => {
    const errors = predAndError.filter((p) => !T.fst(p)(v)).map(T.snd);
    return errors.length > 0 ? E.left(errors) : E.of(v);
  };

부정문 ! 과 인자 적용은 각각 fp-ts/functionnotapply 으로 대체할 수 있다.

import { apply, not } from "fp-ts/function";
 
const validate =
  <T>(predAndError: [Predicate<T>, string][]) =>
  (v: T) => {
    const errors = predAndError.filter((p) => apply(v)(not(T.fst(p)))).map(T.snd);
    return errors.length > 0 ? E.left(errors) : E.of(v);
  };

그리고 이를 flow 로 묶어 인자를 명시하지 않는 함수로 만들 수 있다.

import { flow } from "fp-ts/function";
 
const validate =
  <T>(predAndError: [Predicate<T>, string][]) =>
  (v: T) => {
    const errors = predAndError.filter(flow(T.fst, not, apply(v))).map(T.snd);
    return errors.length > 0 ? E.left(errors) : E.of(v);
  };

여기에 Array.prototype.filter, Array.prototype.mapfp-ts/Arrayfilter, map 으로 대체히자.
길이를 확인하는 errors.length > 0fp-ts/ArrayisNonEmpty 로 대체하자.

import * as A from "fp-ts/Array";
 
const validate =
  <T>(predAndError: [Predicate<T>, string][]) =>
  (v: T) => {
    const failed = A.filter<[Predicate<T>, string]>(flow(T.fst, not, apply(v)))(predAndError);
    const errors = A.map(T.snd)(failed);
    return A.isNonEmpty(errors) ? E.left(errors) : E.of(v);
  };

이제 반환부를 바꿔보자.
먼저 fromPredicate 를 통해 PredicateEither 로 바꾸자.

const validate =
  <T>(predAndError: [Predicate<T>, string][]) =>
  (v: T) => {
    const failed = A.filter<[Predicate<T>, string]>(flow(T.fst, not, apply(v)))(predAndError);
    const errors = A.map(T.snd)(failed);
    return E.fromPredicate(A.isNonEmpty, () => errors)(errors);
  };

하지만 이렇게 되면 Right(v) 가 아닌 Right(errors) 가 반환된다.
생각해보면 지금 필요한 것은 조건 함수가 true 면 그 값을 Left 에 담고 아닐 경우 Right(v) 를 반환하는 함수가 필요하다.
공식 문서를 뒤져봤지만 이런 함수는 없었다.
그래서 대신 fold 를 이용했다.
EitherfoldonLeftonRight 를 받아 각각의 경우 실행하는 함수이다.

const validate =
  <T>(predAndError: [Predicate<T>, string][]) =>
  (v: T) => {
    const failed = A.filter<[Predicate<T>, string]>(flow(T.fst, not, apply(v)))(predAndError);
    const errors = A.map(T.snd)(failed);
    const rightErrors = E.fromPredicate(A.isNonEmpty, () => null)(errors);
    return E.fold(() => E.of(v), E.left<string[], T>)(rightErrors);
  };

여기에 fp-ts/functionconstantconstVoid 를 사용해 인자를 받지 않는 함수를 대체하자.
constant 는 첫 인자를 받아서 저장해 둔 뒤 호출할 때마다 그 값을 반환하는 함수이다.
이 때 호출 시에 들어온 인자는 모두 무시된다.
constVoidconstant(undefined) 와 같은 함수이다.

import { constant, constVoid } from "fp-ts/function";
 
const validate =
  <T>(predAndError: [Predicate<T>, string][]) =>
  (v: T) => {
    const failed = A.filter<[Predicate<T>, string]>(flow(T.fst, not, apply(v)))(predAndError);
    const errors = A.map(T.snd)(failed);
    const rightErrors = E.fromPredicate(A.isNonEmpty, constVoid)(errors);
    return E.fold(constant(E.of(v)), E.left<string[], T>)(rightErrors);
  };

마지막으로 pipe 를 통해 깔끔하게 만들어주자.

 
const validate =
  <T>(predAndError: [Predicate<T>, string][]) =>
  (v: T) =>
    pipe(
      predAndError,
      A.filter(flow(T.fst, not, apply(v))),
      A.map(T.snd),
      E.fromPredicate(A.isNonEmpty, constVoid),
      E.fold(constant(E.of(v)), E.left<string[], T>)
    );

이를 이용해 지난 글에서 만들었던 validateUsername 를 다시 만들어보자.

const validateUsername = (username: unknown) =>
  pipe(
    username,
    validate([
      [minLength(6), "username is too short"],
      [maxLength(20), "username is too long"],
      [isAlphaNumeric, "username must be alphanumeric"],
    ])
  );

물론 문제가 발생할 것이다.
usernameunknown 이지만 이를 검사하는 함수들은 string 을 받는다.
이를 해결하기 위해서는 fp-ts/stringisString 을 사용해 unknownstring 임을 보장해야한다.

import * as S from "fp-ts/string";
 
const validateUsername = (username: unknown) =>
  pipe(
    username,
    validate([
      [S.isString, "username must be a string"],
      [minLength(6), "username is too short"],
      [maxLength(20), "username is too long"],
      [isAlphaNumeric, "username must be alphanumeric"],
    ])
  );

여전히 문제가 발생한다.
S.isString 을 통과하지 못하면 usernameunknown 이므로 다른 검사를 진행해서는 안 된다.
하지만 validate 는 각각의 검사를 독립적으로 시행하기 때문에 S.isString 을 통과하지 못해도 다른 검사를 진행한다.
그렇기 떄문에 먼저 S.isString 검사는 따로 진행한 뒤, 통과 후 다른 검사를 진행해야한다.
그리고 그 결과값은 Either 이므로 flatMap 을 통해 다른 검사를 시행해야한다.

const validateUsername = (username: unknown) =>
  pipe(
    username,
    validate([[S.isString, "username must be a string"]]),
    E.flatMap(
      validate([
        [minLength(6), "username is too short"],
        [maxLength(20), "username is too long"],
        [isAlphaNumeric, "username must be alphanumeric"],
      ])
    )
  );

하지만 여전히 문제가 발생할 것이다.
이는 validatepredAndError 타입이 [Predicate<T>, string][] 인데 S.isStringRefinment<unknown, string> 이기 때문이다.
따라서 validateRefinment 도 받을 수 있도록 바꿔주자.

const validate =
  <U, T extends U>(predAndError: [Predicate<T> | Refinement<U, T>, string][]) =>
  (v: U) =>
    pipe(
      predAndError,
      A.filter(flow(T.fst, not, apply(v as T))),
      A.map(T.snd),
      E.fromPredicate(A.isNonEmpty, constVoid),
      E.fold(constant(E.of(v as T)), E.left<string[], T>)
    );

그럼 문제없이 동작할 것이다.
이제 마찬가지로 validatePassword 를 만들어보자.

const validatePassword = flow(
  validate([[S.isString, "password must be a string"]]),
  E.chain(
    validate([
      [minLength(8), "password is too short"],
      [maxLength(20), "password is too long"],
      [hasAlphaAndNumeric, "password has at least one letter and one number"],
    ])
  )
);

이를 통해 validateUserInput 를 만들면 다음과 같을 것이다.

const validUserInput = ({ username, password }: Body) =>
  pipe(
    E.Do,
    errorsApS("username", validateUsername(username)),
    errorsApS("password", validatePassword(password)),
  );

복합적인 에러를 가진 예제를 만들어 확인해보자.

const examples: Record<string, Body> = {
  valid: {
    username: "username",
    password: "password123",
  },
  ["Has not username and password"]: {},
  ["Too short username, not alphanumeric username"]: {
    username: "user!",
    password: "password123",
  },
  ["Has not password, not alphanumeric username"]: {
    username: "username!",
  },
  ["Too long username, too long password"]: {
    username: "usernameusernameusernameusername",
    password: "passwordpasswordpasswordpassword123",
  },
  ["Has not username, too short password, alphabet only password"]: {
    password: "pas",
  },
};
console.log(R.map(flow(validUserInput, stringify))(examples));
/* Output:
{
  valid: 'Result: username is username, password is password123',
  'Has not username and password': 'Error: username must be a string, password must be a string',
  'Too short username, not alphanumeric username': 'Error: username is too short, username must be alphanumeric',
  'Has not password, not alphanumeric username': 'Error: username must be alphanumeric, password must be a string',
  'Too long username, too long password': 'Error: username is too long, password is too long',
  'Has not username, too short password, alphabet only password': 'Error: username must be a string, password is too short, password has at least one letter and one number'
}
*/

문제 없이 작동하는 것을 확인할 수 있다.

최종 코드

import * as E from "fp-ts/Either";
import * as S from "fp-ts/string";
import * as T from "fp-ts/Tuple";
import * as A from "fp-ts/Array";
import * as R from "fp-ts/Record";
import * as Ap from "fp-ts/Apply";
import { pipe, apply, flow, constant, constVoid } from "fp-ts/function";
import { Predicate, not } from "fp-ts/Predicate";
import { Refinement } from "fp-ts/Refinement";
 
interface Body extends Record<string, string> {}
interface Username extends Body {
  username: string;
}
interface Password extends Body {
  password: string;
}
interface UserInput extends Username, Password {}
 
const minLength = (n: number) => (s: string) => s.length >= n;
const maxLength = (n: number) => (s: string) => s.length <= n;
const includes = (s: RegExp) => (str: string) => s.test(str);
const isAlphaNumeric = includes(/^[a-zA-Z0-9]+$/);
const hasAlphaAndNumeric = includes(/^(?=.*?\d)(?=.*?[a-zA-Z]).+$/);
 
const errorsApplicative = E.getApplicativeValidation(A.getMonoid<string>());
const errorsApS = Ap.apS(errorsApplicative);
 
const validate =
  <U, T extends U>(predAndError: [Predicate<T> | Refinement<U, T>, string][]) =>
  (v: U) =>
    pipe(
      predAndError,
      A.filter(flow(T.fst, not, apply(v as T))),
      A.map(T.snd),
      E.fromPredicate(A.isNonEmpty, constVoid),
      E.fold(constant(E.of(v as T)), E.left<string[], T>)
    );
const validateUsername = (username: unknown) =>
  pipe(
    username,
    validate([[S.isString, "username must be a string"]]),
    E.flatMap(
      validate([
        [minLength(6), "username is too short"],
        [maxLength(20), "username is too long"],
        [isAlphaNumeric, "username must be alphanumeric"],
      ])
    )
  );
const validatePassword = flow(
  validate([[S.isString, "password must be a string"]]),
  E.chain(
    validate([
      [minLength(8), "password is too short"],
      [maxLength(20), "password is too long"],
      [hasAlphaAndNumeric, "password has at least one letter and one number"],
    ])
  )
);
 
const validUserInput = ({ username, password }: Body) =>
  pipe(
    E.Do,
    errorsApS("username", validateUsername(username)),
    errorsApS("password", validatePassword(password))
  );
 
const stringify = E.match<string[], UserInput, string>(
  (err) => `Error: ${err.join(", ")}`,
  ({ username, password }) =>
    `Result: username is ${username}, password is ${password}`
);
const examples: Record<string, Body> = {
  valid: {
    username: "username",
    password: "password123",
  },
  ["Has not username and password"]: {},
  ["Too short username, not alphanumeric username"]: {
    username: "user!",
    password: "password123",
  },
  ["Has not password, not alphanumeric username"]: {
    username: "username!",
  },
  ["Too long username, too long password"]: {
    username: "usernameusernameusernameusername",
    password: "passwordpasswordpasswordpassword123",
  },
  ["Has not username, too short password, alphabet only password"]: {
    password: "pas",
  },
};
console.log(R.map(flow(validUserInput, stringify))(examples));